﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using HashFoo.SqlServer;
using NHibernate;
using NHibernate.Cfg;
using log4net;
using Environment = System.Environment;
    // using HashFoo.SqlServer;

namespace HashFoo.MigratorFoo
{
    /// <summary>
    /// Handles running migration functiontions.
    /// </summary>
    public class MigrationManagerWorker : MarshalByRefObject, IDisposable
    {
        const string VersionFormat = "yyyyMMdd_HHmmss_fffff";

        readonly ILog _logger;
        Configuration _activeDbConfig;
        IEnumerable<DbConfiguration> _dbConfigs;
        ISessionFactory _sessionFactory;

        /// <summary>
        /// Creates a new <see cref="MigrationManagerWorker"/> with a specific configuration.
        /// </summary>
        /// <param name="configuration">The migration configuration</param>
        public MigrationManagerWorker(MigrationConfiguration configuration)
        {
            Configuration = configuration;
            DbConfigurationReflector = new DbConfigurationReflector(Configuration.DbConfigAssemblyPath);
            Generator = new MigrationGenerator
            {
                Config = ActiveDbConfig,
                MigrationInverter = new SimpleMigrationInverter()
            };

            _logger = LogManager.GetLogger("MigratorFoo");
        }

        public IEnumerable<ulong> AllAppliedVersions
        {
            get
            {
                using (var s = SessionFactory.OpenSession())
                {
                    EnsureSchemaVersionTable(s);
                    var versions = GetAllAppliedVersions(s);
                    s.Close();

                    return versions;
                }
            }
        }

        /// <summary>
        /// Gets the Migration Configuration
        /// </summary>
        public MigrationConfiguration Configuration { get; private set; }

        /// <summary>
        /// A list of the the known database configurations to use for migrations.
        /// </summary>
        public string[] DbConfigNames
        {
            get { return DbConfigs.Select(x => x.Name).ToArray(); }
        }

        public bool HasPendingMigrations
        {
            get
            {
                var pendingScripts = GetPendingMigrations();
                return pendingScripts.Count() > 0;
            }
        }

        /// <summary>
        /// Gets the last applied migration version.  0 if none.
        /// </summary>
        public ulong LastAppliedVersion
        {
            get
            {
                using (var session = SessionFactory.OpenSession())
                {
                    EnsureSchemaVersionTable(session);
                    var version = GetLastAppliedVersion(session);
                    session.Close();

                    return version;
                }
            }
        }

        Configuration ActiveDbConfig
        {
            get
            {
                if (_activeDbConfig == null)
                {
                    if (DbConfigs.Count() == 0)
                    {
                        throw new InvalidOperationException(
                            "No IDbConfigRelfector set, can't generate a db config.  This is probably because there is no NHibernate configuration in the migration project.");
                    }

                    _activeDbConfig = (DbConfigs.FirstOrDefault(x => x.IsDefault) ?? DbConfigs.First()).NhibConfig;
                }

                return _activeDbConfig;
            }
        }

        IEnumerable<DbConfiguration> DbConfigs
        {
            get { return _dbConfigs ?? (_dbConfigs = DbConfigurationReflector.GetAllConfigurations()); }
        }

        DbConfigurationReflector DbConfigurationReflector { get; set; }

        MigrationGenerator Generator { get; set; }

        ISessionFactory SessionFactory
        {
            get { return _sessionFactory ?? (_sessionFactory = ActiveDbConfig.BuildSessionFactory()); }
        }

        /// <summary>
        /// Applies any outstanding migrations to the database, up to a specific versions.
        /// </summary>
        /// <param name="toVersion">The version to apply to.  Will apply all by default.</param>
        /// <param name="all">If true, will apply scripts in sub folders as well</param>
        public void Apply(ulong toVersion = ulong.MaxValue, bool all = false)
        {
            _logger.Info(string.Format("Applying migrations to version: {0}", toVersion));
            var upScriptFiles = Directory.GetFiles(
                Configuration.UpScriptsDirectory,
                "*.sql",
                all ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);

            var allVersions = AllAppliedVersions;
            var lastVersion = LastAppliedVersion;

            _logger.Debug(string.Format("Last migration applied: {0}", lastVersion));

            // we want to check that their aren't any scripts that were "orphaned"
            //   an orphaned script is one that is less that the last applied version but has not been applied.
            //   this script will never be applied, and probably signals that there is merge issue.
            var orphanedScripts = upScriptFiles
                .Select(ParseVersionFromFilePath)
                .Where(v => v < lastVersion && v <= toVersion && !allVersions.Contains(v))
                .ToArray();

            if (orphanedScripts.Count() > 0)
            {
                var orphanList = orphanedScripts.Select(v => v.ToString()).Aggregate((c, n) => c + "\n" + n);
                _logger.ErrorFormat(
                    "Cannot apply when you have orphaned scripts.\n" +
                    "An orphaned script is one that is less that the last applied version but has not been applied.\n" +
                    "This script will never be applied, and probably signals that there is merge issue.\nOrphans:\n\n{0}",
                    orphanList);

                return;
            }

            var scriptsToMigrate = upScriptFiles.Where(
                f =>
                {
                    var version = ParseVersionFromFilePath(f);
                    return version > lastVersion && version <= toVersion;
                }).OrderBy(ParseVersionFromFilePath);

            foreach (var file in scriptsToMigrate) ExecuteUpMigration(file);
        }

        /// <summary>
        /// Applies all migrations, including those in sub folders (legacy migrations)
        /// </summary>
        /// <remarks>
        /// This still will only apply versions that are after the current version
        /// </remarks>
        public void ApplyAll()
        {
            Apply(ulong.MaxValue, true);
        }

        /// <summary>
        /// Creates a database from an inmemory migration generation (no migrations are written to files).
        /// Its up to the caller to handle "rollbacks" or "nukes".  No Schema version is created either.
        /// This is basically for <b>testing</b>
        /// </summary>
        public void Create()
        {
            var scripts = Generator.GenerateMigration();

            if (scripts.Empty) return;
            using (var session = SessionFactory.OpenSession())
            {
                var sql = scripts.UpScript;

                _logger.Info("Appling one off create: " + sql);
                using (var transaction = session.BeginTransaction())
                {
                    var query = session.CreateSQLQuery(sql);
                    query.ExecuteUpdate();
                    transaction.Commit();
                }

                session.Close();
            }
        }

        public void Dispose()
        {
            DestroySessionFactory();
        }

        /// <summary>
        /// Generates a set of scripts based on the current database configuration.  Will produce
        /// duplicate scripts if generate has already been called, but the scripts have not been applied.
        /// </summary>
        public void Generate(bool force = false)
        {
            EnsureFolders();

            if (HasPendingMigrations)
            {
                if (!force) throw new InvalidOperationException("There are pending migrations.  Use 'force' to delete them and rerun");
                RemovePendingMigrations();
            }

            var migrationId = DateTime.UtcNow.ToString(VersionFormat);
            _logger.Debug("Generating scripts for migration: " + migrationId);
            var scripts = Generator.GenerateMigration();

            if (!scripts.Empty)
            {
                using (var writer = File.CreateText(Path.Combine(Configuration.UpScriptsDirectory, migrationId + ".sql")))
                {
                    _logger.Debug("Writing UP scripts");
                    writer.Write(scripts.UpScript);
                    writer.Flush();
                    writer.Close();
                }

                using (var writer = File.CreateText(Path.Combine(Configuration.DownScriptsDirectory, migrationId + ".sql")))
                {
                    _logger.Debug("Writing DOWN scripts");
                    writer.Write(scripts.DownScript);
                    writer.Flush();
                    writer.Close();
                }
            }
            else _logger.Info("No database migration changes found.");
        }

        /// <summary>
        /// Will drop and recreate the database being managed by the worker.
        /// </summary>
        /// <param name="masterConnectionString">Will override the connection string to connect to the master database.</param>
        public void Nuke(string masterConnectionString = null)
        {
            //This is probably too brittle of a way to get the connection string (MSSQL-only?)
            // - but it doesn't require opening the connection in order to get it
            var regularConnectionString = ActiveDbConfig.GetProperty("connection.connection_string");
            var dbname = Regex.Match(regularConnectionString, "Initial Catalog=([^;]+);", RegexOptions.IgnoreCase).Groups[1].Value;
            Console.WriteLine(String.Format("About to nuke database {0}", dbname));

            //If a connection to master wasn't specified, create one with find/replacing dbname
            if (String.IsNullOrWhiteSpace(masterConnectionString))
            {
                //Don't just replace the dbName, since username could be of format dbNameUser
                masterConnectionString = 
                    Regex.Replace(
                        regularConnectionString,
                        String.Format("Initial Catalog={0};", dbname),
                        "Initial Catalog=master;", 
                        RegexOptions.IgnoreCase);

                Console.WriteLine("Assuming master connection string:");
                Console.WriteLine(masterConnectionString);
            }

            DbTestingUtil.NukeMsSqlDatabase(dbname, masterConnectionString);
            ApplyAll();
        }

        /// <summary>
        /// Runs all migrations down back to 0, then reapplies the up migrations.
        /// </summary>
        public void Reset()
        {
            Rollback();
            Apply();
        }

        /// <summary>
        /// Rolls back the database to a specific version.
        /// </summary>
        /// <remarks>
        /// There is now way to rollback 'legacy' down scripts.
        /// Legacy down scripts (that should be in a sub folder), represent a release.
        /// We don't want to provide a way to roll back production data.
        /// </remarks>
        /// <param name="backToVersion">The migration version to roll back to.  Rolls all the way back to 0 by default.</param>
        public void Rollback(ulong backToVersion = 0ul)
        {
            _logger.Info("Rolling back database to version: " + backToVersion);
            var downScriptFiles = Directory.GetFiles(Configuration.DownScriptsDirectory, "*.sql", SearchOption.TopDirectoryOnly);
            var allVersions = AllAppliedVersions.ToArray();
            var lastAppliedVersion = LastAppliedVersion;

            var rollbackVersions = allVersions.Where(v => v > backToVersion);
            var rollbackOrphans = downScriptFiles
                .Select(ParseVersionFromFilePath)
                .Where(v => !rollbackVersions.Contains(v) && v > lastAppliedVersion)
                .ToArray();

            if (rollbackOrphans.Count() > 0)
            {
                //TODO: version list should be actual file names as well as ulong version tokens
                var versionList = rollbackOrphans.Select(x => x.ToString()).Aggregate((c, n) => c + "\n" + n);
                _logger.ErrorFormat(
                    "Orphaned script versions detected.\n" +
                    "Orphaned scripts in a rollback are down script .sql files that don't have a corresponding apply version in the database.\n" +
                    "Scripts:\n\n{0}", versionList);

                return;
            }

            var scriptsToMigrate = downScriptFiles.Where(
                f =>
                {
                    var version = ParseVersionFromFilePath(f);
                    return allVersions.Contains(version) && version > backToVersion;
                }).OrderByDescending(ParseVersionFromFilePath);

            for (var i = 0; i < scriptsToMigrate.Count(); i++)
            {
                var file = scriptsToMigrate.ElementAt(i);
                Console.WriteLine("Rolling back: {0}", file);

                ExecuteDownMigration(file);
            }
        }

        /// <summary>
        /// Sets the database configuration to use for migrations.
        /// </summary>
        /// <remarks>
        /// For a list of known names, use <see cref="DbConfigNames"/>
        /// </remarks>
        /// <param name="dbConfigName">The name (case sensitive) of the configuration to use.</param>
        public void SetActiveDbConfig(string dbConfigName)
        {
            var dbConfig = DbConfigs.FirstOrDefault(x => x.Name == dbConfigName);
            if (dbConfig == null) throw new ArgumentOutOfRangeException("dbConfigName", dbConfigName, @"Unknown dbConfigName key.");

            DestroySessionFactory();
            _activeDbConfig = dbConfig.NhibConfig;
            Generator.Config = ActiveDbConfig;
        }

        /// <summary>
        /// Rolls back to the previous version, else to the empty database.
        /// </summary>
        public void Undo()
        {
            var downScriptFiles = Directory.GetFiles(Configuration.DownScriptsDirectory, "*.sql", SearchOption.TopDirectoryOnly);
            var lastVersion = LastAppliedVersion;
            var scriptToMigrate = downScriptFiles
                .Where(f => ParseVersionFromFilePath(f) == lastVersion)
                .FirstOrDefault();

            ExecuteDownMigration(scriptToMigrate);
        }

        static ulong ParseVersion(string version)
        {
            if (string.IsNullOrEmpty(version) || version == "0") return 0;

            var versionString = version.Split('_')
                .Where(t => Regex.IsMatch(t, @"^\d+$"))
                .Aggregate((c, n) => c + n);

            return ulong.Parse(versionString.Substring(0, Math.Min(19, versionString.Length)).PadRight(19, '0'));
        }

        static ulong ParseVersionFromFilePath(string filePath)
        {
            return ParseVersion(Path.GetFileNameWithoutExtension(filePath));
        }

        static string PrettyPrintVersion(string version)
        {
            return version;
            //if (version == "0") return "0";

            // we need to be able to handle multiple formats... putting off.  DMF 20111020
            //return string.Format("{0}-{1}-{2} {3}:{4}:{5}.{6}",
            //                     version.Substring(0, 4), version.Substring(4, 2), version.Substring(6, 2),
            //                     version.Substring(9, 2), version.Substring(11, 2), version.Substring(13, 2), version.Substring(16, 5));
        }

        void AddVersion(ISession session, string version)
        {
            var sql = string.Format(@"insert into [{0}] values ('{1}', '{2}')",
                                    Configuration.SchemaVersionTableName, version, DateTime.UtcNow);

            _logger.Debug(string.Format("Updating schema version: {0}{1}", Environment.NewLine, sql));
            session.CreateSQLQuery(sql).ExecuteUpdate();
        }

        void DestroySessionFactory()
        {
            if (_sessionFactory != null)
            {
                _sessionFactory.Close();
                _sessionFactory.Dispose();
                _sessionFactory = null;
            }
        }

        void EnsureFolders()
        {
            Directory.CreateDirectory(Configuration.UpScriptsDirectory);
            Directory.CreateDirectory(Configuration.DownScriptsDirectory);
        }

        void EnsureSchemaVersionTable(ISession session)
        {
            session.CreateSQLQuery(
                string.Format(
                    @"if object_id('{0}') is null
                  BEGIN
                      create table [{0}] (
                        version varchar(2000) not null,
                        createdat datetime not null,
                        primary key (version)
                      );
                      INSERT INTO {0}
                      VALUES ('0', '{1}');
                  END", Configuration.SchemaVersionTableName, DateTime.UtcNow)).ExecuteUpdate();
        }

        void ExecuteDownMigration(string filePath, string version = null)
        {
            ExecuteMigration(
                filePath,
                version,
                RemoveVersion);
        }

        void ExecuteMigration(string filePath, string version, Action<ISession, string> versionUpdater)
        {
            var sql = File.ReadAllText(filePath);
            if (string.IsNullOrEmpty(sql))
            {
                _logger.Info(string.Format("Skipping empty migration file: {0}", filePath));
                return;
            }

            _logger.Info(string.Format("Applying migration: {0}{1}{2}", filePath, Environment.NewLine, sql));

            using (var session = SessionFactory.OpenSession())
            using (var transaction = session.BeginTransaction())
            {
                _logger.Debug("Ensuring schema table...");
                EnsureSchemaVersionTable(session);

                // this tightly binds us to MSSql... DMF 20101102
                foreach (var line in sql.Split(new[] { "GO" }, StringSplitOptions.RemoveEmptyEntries))
                {
                    _logger.DebugFormat("Running sub part of migration script:\n{0}", line);
                    using (var cmd = session.Connection.CreateCommand())
                    {
                        transaction.Enlist(cmd);
                        cmd.CommandText = line;
                        cmd.ExecuteNonQuery();
                    }
                }

                _logger.Debug("Updating schema version...");
                var updateToVersion = version ?? Path.GetFileNameWithoutExtension(filePath);
                _logger.InfoFormat("executed migration: {0}", PrettyPrintVersion(updateToVersion));

                versionUpdater(session, updateToVersion);

                transaction.Commit();
            }
        }

        void ExecuteUpMigration(string filePath, string version = null)
        {
            ExecuteMigration(
                filePath,
                version,
                AddVersion);
        }

        IEnumerable<ulong> GetAllAppliedVersions(ISession session)
        {
            var result = session.CreateSQLQuery(
                string.Format(
                    @"select version from [{0}]
                        order by createdat desc;", Configuration.SchemaVersionTableName))
                .AddScalar("version", NHibernateUtil.String).List();

            var versions = new List<ulong>();
            if (result == null || result.Count == 0) return versions;

            return result.Cast<string>().Select(ParseVersion).OrderByDescending(x => x);
        }

        ulong GetLastAppliedVersion(ISession session)
        {
            return GetAllAppliedVersions(session).First();
        }

        IEnumerable<string> GetPendingMigrations()
        {
            var version = LastAppliedVersion;
            var files = 
                Directory.GetFiles(Configuration.UpScriptsDirectory, "*.sql")
                .Concat(Directory.GetFiles(Configuration.DownScriptsDirectory, "*.sql"))
                .ToList();

            var pendingScripts = files.Where(f => ParseVersionFromFilePath(f) > version);
            return pendingScripts;
        }

        /// <summary>
        /// Removes any pending (not applied) migration sql files.
        /// </summary>
        public void RemovePendingMigrations()
        {
            EnsureFolders();
            _logger.Debug("Last applied version: " + LastAppliedVersion);
            var pendingMigrations = GetPendingMigrations();

            foreach (var f in pendingMigrations)
            {
                _logger.Warn(string.Format("Deleting migration script: {0}", f));
                File.Delete(f);
            }
        }

        void RemoveVersion(ISession session, string version)
        {
            var sql = string.Format("delete from [{0}] where version = '{1}'", Configuration.SchemaVersionTableName, version);

            _logger.Debug(string.Format("rolling back version: {0}\n{1}", PrettyPrintVersion(version), sql));
            session.CreateSQLQuery(sql).ExecuteUpdate();
        }
    }
}